정규화가 중복과 이상 현상을 줄이는 원리
정규화가 중복과 이상 현상을 줄이는 원리
정규화는 속성이 올바른 키에 의존하도록 테이블을 분해한다. 분해 뒤 JOIN으로 원래 의미를 복원할 수 있고 무손실 결합과 종속성 보존을 확인한다.
목차
- #문제가 되는 상황
- #한 테이블에 여러 사실을 저장할 때 생기는 이상
- #함수 종속으로 사실의 주인을 찾는다
- #1정규형: 반복 그룹을 행으로 바꾸기
- #2정규형: 복합 키 일부에만 의존하는 속성 분리
- #3정규형: 키가 아닌 속성 사이의 의존 제거
- #무손실 결합과 종속성 보존
- #정규화가 JOIN을 무조건 느리게 만드는가
- #제약 조건까지 있어야 모델이 완성된다
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
주문 화면에 필요한 값을 한 번에 읽으려고 주문, 고객, 상품 정보를 모두 한 테이블에 저장하면 처음에는 query가 단순하다. 하지만 같은 고객 이름과 상품 가격이 주문 항목마다 반복되고, 일부 행만 수정되면 어떤 값이 맞는지 알 수 없게 된다.
정규화는 테이블을 작게 나누는 형식적인 작업이 아니라 한 사실을 한 곳에 저장하고 그 사실의 주인이 되는 키를 찾는 과정이다. 분해 뒤에는 외래 키와 JOIN으로 관계를 복원한다. 무작정 테이블 수를 늘리기보다 어떤 함수 종속과 이상 현상을 해결하는지 설명할 수 있어야 한다.
고객·주문·상품 데이터와 ID는 정규화 과정을 설명하기 위한 가상 예제다. 실제 주문이나 사용자 정보를 사용하지 않았다.
한 테이블에 여러 사실을 저장할 때 생기는 이상
다음 order_rows가 주문 항목과 고객·상품 정보를 모두 저장한다고 하자.
| order_id | line_no | customer_id | customer_name | product_id | product_name | unit_price | quantity |
|---|---|---|---|---|---|---|---|
| 1001 | 1 | 42 | Mina | 7 | Keyboard | 50000 | 1 |
| 1001 | 2 | 42 | Mina | 9 | Mouse | 30000 | 2 |
| 1002 | 1 | 42 | Mina | 7 | Keyboard | 50000 | 1 |
갱신 이상
고객 이름이 바뀌면 해당 고객의 모든 주문 행을 수정해야 한다. 한 행이 빠지면 Mina와 새 이름이 동시에 존재한다.
UPDATE order_rows
SET customer_name = 'Mina Kim'
WHERE customer_id = 42;
삽입 이상
아직 주문되지 않은 새 상품을 등록하려 해도 order_id, line_no가 없어서 같은 테이블에 자연스럽게 저장할 수 없다. 상품이라는 사실이 주문 존재에 종속되어 버린다.
삭제 이상
상품 9가 들어간 마지막 주문 행을 삭제하면 상품 이름과 가격 정보까지 함께 사라진다. 주문 항목 삭제와 상품 master 삭제는 서로 다른 업무인데 한 행에 섞여 있기 때문이다.
함수 종속으로 사실의 주인을 찾는다
함수 종속 X → Y는 X 값이 정해지면 Y 값이 하나로 결정된다는 뜻이다.
order_id → customer_id, ordered_at
customer_id → customer_name
product_id → product_name, current_price
(order_id, line_no) → product_id, quantity, ordered_unit_price
이 종속을 보면 각 사실의 주인을 찾을 수 있다.
- 고객 이름은
customer_id에 속한다. - 상품 이름과 현재 가격은
product_id에 속한다. - 주문 당시 판매 가격과 수량은 주문 항목 키에 속한다.
여기서 current_price와 ordered_unit_price를 구분해야 한다. 주문 후 상품 가격이 바뀌어도 과거 결제 금액은 바뀌면 안 되므로 주문 당시 가격은 의도적으로 order item에 snapshot으로 저장한다. 겉으로 같은 숫자여도 의미가 다른 사실이다.
정규화는 같은 값을 무조건 한 컬럼으로 합치는 작업이 아니다. 현재 상품 가격과 계약 당시 가격처럼 시간과 업무 의미가 다른 값은 별도 사실이다.
1정규형: 반복 그룹을 행으로 바꾸기
한 주문 행에 상품 ID를 쉼표 문자열로 넣는 형태를 생각해 보자.
order_id=1001
product_ids="7,9"
quantities="1,2"
상품 하나의 수량을 갱신하거나 외래 키를 걸기 어렵고 두 배열의 위치가 어긋날 수 있다. 반복되는 주문 항목을 각각의 행으로 만든다.
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
line_no SMALLINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
ordered_unit_price BIGINT NOT NULL,
PRIMARY KEY (order_id, line_no)
);
1정규형을 “모든 값은 절대 나눌 수 없는 원자값”이라고만 외우면 JSON·주소 같은 값에서 혼란이 생긴다. 중요한 것은 관계 연산과 제약을 적용해야 하는 반복 데이터를 한 셀에 숨기지 않는 것이다. 주소 전체를 표시용 문자열 하나로 다루는 업무와 우편번호별 검색이 필요한 업무는 필요한 분해 수준이 다르다.
2정규형: 복합 키 일부에만 의존하는 속성 분리
order_items의 기본 키가 (order_id, line_no)인데 여기에 ordered_at을 저장하면 ordered_at은 복합 키 전체가 아니라 order_id만으로 결정된다.
(order_id, line_no) → ordered_at
order_id → ordered_at
이런 부분 함수 종속을 분리한다.
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
ordered_at DATETIME NOT NULL
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
line_no SMALLINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
ordered_unit_price BIGINT NOT NULL,
PRIMARY KEY (order_id, line_no),
FOREIGN KEY (order_id) REFERENCES orders(id)
);
단일 컬럼 기본 키만 있는 테이블에서는 비주요 속성이 키 일부에만 의존하는 상황이 없으므로 2정규형 위반을 따질 지점도 다르다.
3정규형: 키가 아닌 속성 사이의 의존 제거
orders에 customer_id와 customer_name을 모두 저장하면 다음 종속이 있다.
order_id → customer_id → customer_name
customer_name은 order key에 직접 속한 사실이 아니라 customer를 통해 이행적으로 의존한다. customers 테이블로 분리한다.
CREATE TABLE customers (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
ALTER TABLE orders
ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id);
조회 시에는 JOIN으로 현재 고객 이름을 가져온다.
SELECT
o.id AS order_id,
o.ordered_at,
c.id AS customer_id,
c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = :order_id;
주문 시점의 배송 수령인 이름을 보존해야 한다면 그것은 현재 고객 이름과 다른 주문 snapshot이므로 orders 또는 shipping address snapshot에 저장할 수 있다. 요구를 “중복 제거” 하나로만 판단하지 않는다.
무손실 결합과 종속성 보존
테이블을 분해한 뒤 JOIN했을 때 원래의 유효한 정보를 복원할 수 있어야 한다. 이를 무손실 결합 관점으로 본다.
flowchart LR
C[customers] -->|customer_id| O[orders]
O -->|order_id| I[order_items]
P[products] -->|product_id| I
C --> J[주문 상세 JOIN]
O --> J
I --> J
P --> J공통 컬럼이 한쪽 테이블의 key이고 외래 키로 관계를 보장하면 예상하지 않은 가짜 조합이 생기는 위험을 줄일 수 있다.
종속성 보존은 원래의 업무 제약을 분해된 각 테이블의 제약으로 검사할 수 있는지를 본다. 분해는 아름답지만 특정 유일성 규칙을 확인하려면 여러 테이블 JOIN이 항상 필요한 구조가 될 수도 있다. unique·foreign key·check constraint로 주요 규칙을 보존할 수 있는지 확인한다.
정규화가 JOIN을 무조건 느리게 만드는가
JOIN이 있다는 이유만으로 반정규화하면 안 된다. PK·FK index를 사용한 작은 관계 JOIN은 DB가 잘 처리할 수 있고, 중복된 큰 문자열을 한 테이블에 반복 저장하는 것이 오히려 I/O와 cache 효율을 악화시킬 수 있다.
다음 순서로 판단한다.
- 먼저 정합성을 설명하기 쉬운 정규화 모델을 만든다.
- 실제 query와 데이터량을 준비한다.
EXPLAIN ANALYZE와 p95/p99 latency를 측정한다.- index, query shape, pagination을 먼저 개선한다.
- 여전히 병목이면 목적이 분명한 반정규화·cache를 검토한다.
읽기 모델을 별도 summary table로 만들더라도 normalized table을 원본으로 두고 재생성·불일치 탐지 경로를 설계한다. 이는 반정규화를 고려해야 하는 시점에서 이어서 다룬다.
제약 조건까지 있어야 모델이 완성된다
테이블을 나누었지만 외래 키와 unique constraint가 없으면 고아 주문과 중복 고객이 생길 수 있다.
ALTER TABLE customers
ADD CONSTRAINT uq_customers_email UNIQUE (email_normalized);
ALTER TABLE order_items
ADD CONSTRAINT fk_order_items_product
FOREIGN KEY (product_id) REFERENCES products(id),
ADD CONSTRAINT ck_order_items_quantity
CHECK (quantity > 0),
ADD CONSTRAINT ck_order_items_price
CHECK (ordered_unit_price >= 0);
정규화는 컬럼 배치이고 제약은 허용되는 데이터 상태를 정의한다. 애플리케이션 validation과 DB constraint를 함께 사용해 모든 쓰기 경로에서 모델을 지킨다.
실전 점검 목록
- 같은 사실이 여러 행에 반복되어 부분 갱신될 가능성이 있는가?
- 각 속성이 어느 후보 키에 함수적으로 의존하는가?
- 복합 키 일부에만 의존하는 속성이 섞여 있는가?
- 키가 아닌 속성 사이의 이행 종속이 있는가?
- 분해 뒤 JOIN이 무손실이고 주요 종속을 제약으로 보존하는가?
- 현재 값과 거래 시점 snapshot을 구분했는가?
- 성능 문제는 실행 계획과 운영 지연으로 측정했는가?
정규화는 속성이 올바른 키에 의존하도록 테이블을 분해한다. 분해 뒤 JOIN으로 원래 의미를 복원할 수 있고 무손실 결합과 종속성 보존을 확인한다.
결론
정규화는 한 사실이 올바른 후보 키에 의존하도록 관계를 분해해 삽입·갱신·삭제 이상을 줄이는 과정이다. 반복 그룹, 부분 종속, 이행 종속을 단계적으로 찾되 현재 값과 거래 시점 snapshot처럼 의미가 다른 데이터는 구분한다. 분해 뒤에는 무손실 JOIN과 종속성 보존, 외래 키·unique·check 제약까지 확인해야 모델이 실제 쓰기 경로에서도 유지된다.